/** * Model Schema API Endpoint * * Fetches parameter schema for a specific model from its provider. * Returns simplified parameter list for UI rendering. * * GET /api/models/:modelId?provider=replicate|fal * * Headers: * - X-Replicate-Key: Required for Replicate models * - X-Fal-Key: Optional for fal.ai models * * Response: * { * success: true, * parameters: ModelParameter[], * cached: boolean * } */ import { NextRequest, NextResponse } from "next/server"; import { ProviderType } from "@/types"; import { ModelParameter, ModelInput } from "@/lib/providers/types"; // Cache for model schemas (10 minute TTL) const schemaCache = new Map(); const CACHE_TTL = 10 * 60 * 1000; // 10 minutes // Image input property patterns const IMAGE_INPUT_PATTERNS = [ "image_url", "image_urls", "image", "image_input", "input_image", "first_frame", "last_frame", "tail_image_url", "start_image", "end_image", "reference_image", "init_image", "mask_image", "control_image", ]; // Text input properties const TEXT_INPUT_NAMES = ["prompt", "negative_prompt"]; // Properties that start with "image_" but are NOT image inputs const IMAGE_PREFIX_EXCLUSIONS = ["image_size"]; // Parameters to filter out (internal/system params) const EXCLUDED_PARAMS = new Set([ "webhook", "webhook_events_filter", "sync_mode", "disable_safety_checker", "go_fast", "enable_safety_checker", "output_format", "output_quality", "request_id", ]); // Parameters we want to surface (user-relevant) const PRIORITY_PARAMS = new Set([ "seed", "num_inference_steps", "inference_steps", "steps", "guidance_scale", "guidance", "negative_prompt", "width", "height", "image_size", "num_outputs", "num_images", "scheduler", "strength", "cfg_scale", "lora_scale", ]); interface SchemaSuccessResponse { success: true; parameters: ModelParameter[]; inputs: ModelInput[]; cached: boolean; } interface SchemaErrorResponse { success: false; error: string; } type SchemaResponse = SchemaSuccessResponse | SchemaErrorResponse; /** * Convert property name to human-readable label */ function toLabel(name: string): string { return name .replace(/_url$/, "") .replace(/_/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); } /** * Check if property is an image input based on BOTH schema type AND name. * * Image inputs must be strings (URLs or base64) or arrays of strings. * Integers, booleans, numbers with "image" in the name are NOT image inputs. */ function isImageInput(name: string, prop: Record): boolean { // First check: must be a string type (images are URLs or base64 strings) // Integers, booleans, numbers are NEVER image inputs regardless of name const propType = prop.type as string | undefined; if (propType !== "string" && propType !== "array") { return false; } // For arrays, check if items are strings (or unspecified - be lenient) if (propType === "array") { const items = prop.items as Record | undefined; // Only reject if items.type is explicitly specified AND not "string" // Many schemas don't specify items type for image arrays if (items && items.type && items.type !== "string") { return false; } } // Check exclusions (e.g., image_size is a parameter, not an image input) if (IMAGE_PREFIX_EXCLUSIONS.includes(name)) { return false; } // Check format hints (OpenAPI format field) - strong signal for image URLs const format = prop.format as string | undefined; if (format === "uri" || format === "data-uri" || format === "binary") { // Only treat as image if name also suggests it's an image if (IMAGE_INPUT_PATTERNS.includes(name) || name.endsWith("_image") || name.startsWith("image_") || name.includes("_image_")) { return true; } } // Check description for image-related keywords const description = (prop.description as string || "").toLowerCase(); if (description.includes("image url") || description.includes("base64 image") || description.includes("data uri") || description.includes("image file") || description.includes("url of the image") || description.includes("path to image")) { return true; } // Check explicit patterns (exact matches like "image_url", "image") if (IMAGE_INPUT_PATTERNS.includes(name)) { return true; } // More restrictive name pattern matching for strings // Exclude names that suggest counts or settings rather than actual images if (name.includes("_images") || // max_images, num_images name.includes("guidance") || // image_guidance_scale name.includes("generation") || // sequential_image_generation name.includes("_count") || // image_count name.includes("_size") || // image_size (already in exclusions but belt-and-suspenders) name.includes("_scale")) { // image_scale return false; } // Finally, check name patterns for remaining string types return name.endsWith("_image") || name.startsWith("image_") || name.includes("_image_"); } /** * Check if property is a text input */ function isTextInput(name: string): boolean { return TEXT_INPUT_NAMES.includes(name); } /** * Resolve a $ref reference in OpenAPI schema * E.g., "#/components/schemas/AspectRatio" -> schema object */ function resolveRef( ref: string, schemaComponents: Record ): Record | null { // Parse reference path like "#/components/schemas/AspectRatio" const match = ref.match(/^#\/components\/schemas\/(.+)$/); if (!match) return null; const schemaName = match[1]; const resolved = schemaComponents[schemaName] as Record | undefined; return resolved || null; } /** * Convert OpenAPI schema property to ModelParameter */ function convertSchemaProperty( name: string, prop: Record, required: string[], schemaComponents?: Record ): ModelParameter | null { // Skip excluded parameters if (EXCLUDED_PARAMS.has(name)) { return null; } // Determine type and extract enum from allOf/$ref if present let type: ModelParameter["type"] = "string"; let enumValues: unknown[] | undefined; let resolvedDefault: unknown; let resolvedDescription: string | undefined; const schemaType = prop.type as string | undefined; const allOf = prop.allOf as Array> | undefined; if (schemaType === "integer") { type = "integer"; } else if (schemaType === "number") { type = "number"; } else if (schemaType === "boolean") { type = "boolean"; } else if (schemaType === "array") { type = "array"; } else if (allOf && allOf.length > 0 && schemaComponents) { // Handle allOf with $ref - resolve references and extract enum/type for (const item of allOf) { const itemRef = item.$ref as string | undefined; if (itemRef) { const resolved = resolveRef(itemRef, schemaComponents); if (resolved) { // Extract type from resolved schema if (resolved.type === "integer") type = "integer"; else if (resolved.type === "number") type = "number"; else if (resolved.type === "boolean") type = "boolean"; // Extract enum from resolved schema if (Array.isArray(resolved.enum)) { enumValues = resolved.enum; } // Extract default from resolved schema if (resolved.default !== undefined && resolvedDefault === undefined) { resolvedDefault = resolved.default; } // Extract description from resolved schema if (resolved.description && !resolvedDescription) { resolvedDescription = resolved.description as string; } } } else if (Array.isArray(item.enum)) { // Direct enum in allOf item enumValues = item.enum; } } } const parameter: ModelParameter = { name, type, description: (prop.description as string | undefined) || resolvedDescription, default: prop.default !== undefined ? prop.default : resolvedDefault, required: required.includes(name), }; // Add constraints if (typeof prop.minimum === "number") { parameter.minimum = prop.minimum; } if (typeof prop.maximum === "number") { parameter.maximum = prop.maximum; } // Use enum from property directly, or from resolved $ref if (Array.isArray(prop.enum)) { parameter.enum = prop.enum; } else if (enumValues) { parameter.enum = enumValues; } return parameter; } interface ExtractedSchema { parameters: ModelParameter[]; inputs: ModelInput[]; } /** * Fetch and parse schema from Replicate */ async function fetchReplicateSchema( modelId: string, apiKey: string ): Promise { const [owner, name] = modelId.split("/"); const response = await fetch( `https://api.replicate.com/v1/models/${owner}/${name}`, { headers: { Authorization: `Bearer ${apiKey}`, }, } ); if (!response.ok) { throw new Error(`Replicate API error: ${response.status}`); } const data = await response.json(); // Extract schema from latest_version.openapi_schema const openApiSchema = data.latest_version?.openapi_schema; if (!openApiSchema) { return { parameters: [], inputs: [] }; } // Navigate to Input schema const inputSchema = openApiSchema.components?.schemas?.Input; if (!inputSchema || typeof inputSchema !== "object") { return { parameters: [], inputs: [] }; } // Pass components.schemas for $ref resolution const schemaComponents = openApiSchema.components?.schemas as Record | undefined; return extractParametersFromSchema(inputSchema as Record, schemaComponents); } /** * Fetch and parse schema from fal.ai using Model Search API * Uses: GET https://api.fal.ai/v1/models?endpoint_id={modelId}&expand=openapi-3.0 */ async function fetchFalSchema( modelId: string, apiKey: string | null ): Promise { const headers: Record = {}; if (apiKey) { headers["Authorization"] = `Key ${apiKey}`; } // Use fal.ai Model Search API with OpenAPI expansion const url = `https://api.fal.ai/v1/models?endpoint_id=${encodeURIComponent(modelId)}&expand=openapi-3.0`; const response = await fetch(url, { headers }); if (!response.ok) { // Return empty params if API fails so generation still works return { parameters: [], inputs: [] }; } const data = await response.json(); // Response is { models: [{ openapi: {...}, ... }] } const modelData = data.models?.[0]; if (!modelData?.openapi) { return { parameters: [], inputs: [] }; } const spec = modelData.openapi; // Find POST endpoint with requestBody - paths are keyed by full endpoint path let inputSchema: Record | null = null; for (const pathObj of Object.values(spec.paths || {})) { const postOp = (pathObj as Record)?.post as Record | undefined; const reqBody = postOp?.requestBody as Record | undefined; const content = reqBody?.content as Record> | undefined; const jsonContent = content?.["application/json"]; if (jsonContent?.schema) { const schema = jsonContent.schema as Record; // Handle $ref - resolve from components.schemas if (schema.$ref && typeof schema.$ref === "string") { const refPath = schema.$ref.replace("#/components/schemas/", ""); const resolvedSchema = spec.components?.schemas?.[refPath] as Record | undefined; if (resolvedSchema) { inputSchema = resolvedSchema; break; } } else if (schema.properties) { inputSchema = schema; break; } } } if (!inputSchema) { return { parameters: [], inputs: [] }; } // Pass components.schemas for $ref resolution const schemaComponents = spec.components?.schemas as Record | undefined; return extractParametersFromSchema(inputSchema, schemaComponents); } /** * Extract ModelParameters and ModelInputs from an OpenAPI schema object */ function extractParametersFromSchema( schema: Record, schemaComponents?: Record ): ExtractedSchema { const properties = schema.properties as Record> | undefined; const required = (schema.required as string[]) || []; if (!properties) { return { parameters: [], inputs: [] }; } const parameters: ModelParameter[] = []; const inputs: ModelInput[] = []; for (const [name, prop] of Object.entries(properties)) { // Check if this is a connectable input (image or text) // Pass both name AND prop to check schema type, not just name if (isImageInput(name, prop)) { inputs.push({ name, type: "image", required: required.includes(name), label: toLabel(name), description: prop.description as string | undefined, isArray: prop.type === "array", }); continue; } if (isTextInput(name)) { inputs.push({ name, type: "text", required: required.includes(name), label: toLabel(name), description: prop.description as string | undefined, isArray: prop.type === "array", }); continue; } // Otherwise it's a parameter const param = convertSchemaProperty(name, prop, required, schemaComponents); if (param) { parameters.push(param); } } // Sort parameters: priority params first, then alphabetically parameters.sort((a, b) => { const aIsPriority = PRIORITY_PARAMS.has(a.name); const bIsPriority = PRIORITY_PARAMS.has(b.name); if (aIsPriority && !bIsPriority) return -1; if (!aIsPriority && bIsPriority) return 1; return a.name.localeCompare(b.name); }); // Sort inputs: required first, then by type (image before text), then alphabetically inputs.sort((a, b) => { if (a.required !== b.required) return a.required ? -1 : 1; if (a.type !== b.type) return a.type === "image" ? -1 : 1; return a.name.localeCompare(b.name); }); return { parameters, inputs }; } export async function GET( request: NextRequest, { params }: { params: Promise<{ modelId: string }> } ): Promise> { // Await params before accessing properties const { modelId } = await params; const decodedModelId = decodeURIComponent(modelId); const provider = request.nextUrl.searchParams.get("provider") as ProviderType | null; if (!provider || (provider !== "replicate" && provider !== "fal")) { return NextResponse.json( { success: false, error: "Invalid or missing provider. Use ?provider=replicate or ?provider=fal", }, { status: 400 } ); } // Check cache const cacheKey = `${provider}:${decodedModelId}`; const cached = schemaCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_TTL) { return NextResponse.json({ success: true, parameters: cached.parameters, inputs: cached.inputs, cached: true, }); } try { let result: ExtractedSchema; if (provider === "replicate") { // User-provided key takes precedence over env variable const apiKey = request.headers.get("X-Replicate-Key") || process.env.REPLICATE_API_KEY; if (!apiKey) { return NextResponse.json( { success: false, error: "Replicate API key required. Add REPLICATE_API_KEY to .env.local or configure in Settings.", }, { status: 401 } ); } result = await fetchReplicateSchema(decodedModelId, apiKey); } else { // User-provided key takes precedence over env variable const apiKey = request.headers.get("X-Fal-Key") || process.env.FAL_API_KEY || null; if (!apiKey) { return NextResponse.json( { success: false, error: "fal.ai API key not configured. Add FAL_API_KEY to .env.local or configure in Settings.", }, { status: 401 } ); } result = await fetchFalSchema(decodedModelId, apiKey); } // Cache the result schemaCache.set(cacheKey, { ...result, timestamp: Date.now() }); return NextResponse.json({ success: true, parameters: result.parameters, inputs: result.inputs, cached: false, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; console.error(`[ModelSchema] Error fetching ${decodedModelId}: ${errorMessage}`); return NextResponse.json( { success: false, error: errorMessage, }, { status: 500 } ); } }